iT邦幫忙

2025 iThome 鐵人賽

DAY 10
0
Software Development

Laravel Pest TDD 實戰:從零開始的測試驅動開發系列 第 10

Day 10 - 重構與測試:讓程式碼持續進化 🔧

  • 分享至 

  • xImage
  •  

還記得第一次接手別人寫的程式碼嗎?那種「這是什麼?」的困惑、「為什麼要這樣寫?」的疑問,以及「我該從哪裡開始改?」的無助感。每個開發者都有過這樣的經歷。

經過前九天的學習,我們掌握了 TDD 的基本工具。今天來學習「重構與測試」:在不改變程式外部行為的前提下,改善程式碼內部結構。這就像整理房間一樣,外觀看起來還是同一個房間,但內部變得井然有序、使用起來更方便。

有了測試作為安全網,重構就變得安全而有信心。測試會告訴你:「重構是否保持了原有的行為」。這就像走鋼索時下面有安全網,讓你可以大膽前進。

本日學習地圖 🗺️

重構之旅啟程!
├── 第一站:理解重構的本質
├── 第二站:測試驅動的重構實戰
├── 第三站:掌握重構技巧
├── 第四站:重構最佳實踐
└── 終點站:10天學習總結與回顧

學習目標 🎯

今天你將學會:

  • 理解重構的概念和重要性
  • 掌握常見的重構技巧
  • 學會在測試保護下進行安全重構
  • 總結前 10 天的 TDD 學習成果

什麼是重構?🔄

重構是透過小步驟改善程式碼結構,同時保持程式的外部行為不變。Martin Fowler 在《Refactoring》一書中說:「重構是在不改變軟體可觀察行為的前提下,改善其內部結構」。

重構 vs 重寫

很多人常把重構和重寫搞混:

特性 重構 重寫
改變外部行為 ❌ 否 ✅ 可能
需要測試保護 ✅ 必須 ⚠️ 不一定
風險程度
進行方式 小步驟 大範圍
時間投入 持續進行 一次性

為什麼要重構? 💡

  1. 提升可讀性:讓程式碼更容易理解

    • 有意義的命名
    • 清晰的結構
    • 適當的抽象層次
  2. 減少重複:遵循 DRY (Don't Repeat Yourself) 原則

    • 消除複製貼上的程式碼
    • 提取共用邏輯
    • 建立可重用元件
  3. 提升維護性:修改和擴展更容易

    • 降低修改成本
    • 減少出錯機會
    • 加快開發速度
  4. 降低複雜度:簡化複雜的邏輯

    • 分解大函數
    • 簡化條件判斷
    • 改善資料結構

何時該重構? ⏰

三法則(Rule of Three):

  1. 第一次做某件事時,直接做
  2. 第二次做類似的事時,會有點不情願但還是做了
  3. 第三次做類似的事時,就該重構了

測試驅動的重構 🚀

重構的黃金法則:在重構之前,你必須有穩固的測試。沒有測試的重構是危險的,就像沒有安全帶就開車一樣。

重構的安全步驟

1. 確認測試都是綠燈 ✅
2. 執行小步驟重構 🔧
3. 執行測試驗證 🧪
4. 如果測試失敗,立即回復 ↩️
5. 重複直到完成 🔄

讓我們透過實際案例來體驗重構的過程:

建立 app/Services/Calculator.php

<?php

namespace App\Services;

use InvalidArgumentException;

class Calculator
{
    public function calculate(float $a, float $b, string $operation): float
    {
        if ($operation === 'add') {
            return $a + $b;
        } elseif ($operation === 'subtract') {
            return $a - $b;
        } elseif ($operation === 'multiply') {
            return $a * $b;
        } elseif ($operation === 'divide') {
            if ($b === 0.0) {
                throw new InvalidArgumentException('Cannot divide by zero');
            }
            return $a / $b;
        } else {
            throw new InvalidArgumentException('Unknown operation');
        }
    }
}

建立 tests/Unit/Day10/CalculatorBeforeTest.php

<?php

use App\Services\Calculator;

describe('Calculator - Before Refactor', function () {
    beforeEach(function () {
        $this->calculator = new Calculator();
    });

    it('performs addition', function () {
        expect($this->calculator->calculate(5.0, 3.0, 'add'))->toBe(8.0);
    });

    it('performs subtraction', function () {
        expect($this->calculator->calculate(5.0, 3.0, 'subtract'))->toBe(2.0);
    });

    it('throws error when dividing by zero', function () {
        expect(fn() => $this->calculator->calculate(5.0, 0.0, 'divide'))
            ->toThrow(InvalidArgumentException::class);
    });
});

重構前後對比範例

執行重構 ⚡

現在讓我們執行重構,將 if-elseif 結構改為更優雅的 match 表達式(PHP 8 新特性):

更新 app/Services/Calculator.php

<?php

namespace App\Services;

use InvalidArgumentException;

class Calculator
{
    public function calculate(float $a, float $b, string $operation): float
    {
        return match ($operation) {
            'add' => $this->add($a, $b),
            'subtract' => $this->subtract($a, $b),
            'multiply' => $this->multiply($a, $b),
            'divide' => $this->divide($a, $b),
            default => throw new InvalidArgumentException('Unknown operation')
        };
    }

    private function add(float $a, float $b): float
    {
        return $a + $b;
    }

    private function subtract(float $a, float $b): float
    {
        return $a - $b;
    }

    private function multiply(float $a, float $b): float
    {
        return $a * $b;
    }

    private function divide(float $a, float $b): float
    {
        if ($b === 0.0) {
            throw new InvalidArgumentException('Cannot divide by zero');
        }
        return $a / $b;
    }
}

驗證重構正確性

建立 tests/Unit/Day10/CalculatorAfterTest.php

<?php

use App\Services\Calculator;

describe('Calculator - After Refactor', function () {
    beforeEach(function () {
        $this->calculator = new Calculator();
    });

    it('all operations still work correctly', function () {
        expect($this->calculator->calculate(5.0, 3.0, 'add'))->toBe(8.0);
        expect($this->calculator->calculate(5.0, 3.0, 'subtract'))->toBe(2.0);
        expect($this->calculator->calculate(5.0, 3.0, 'multiply'))->toBe(15.0);
        expect($this->calculator->calculate(6.0, 2.0, 'divide'))->toBe(3.0);
    });

    it('error handling preserved', function () {
        expect(fn() => $this->calculator->calculate(5.0, 0.0, 'divide'))
            ->toThrow(InvalidArgumentException::class, 'Cannot divide by zero');
        expect(fn() => $this->calculator->calculate(5.0, 3.0, 'unknown'))
            ->toThrow(InvalidArgumentException::class, 'Unknown operation');
    });
});

常用重構技巧 🎯

1. 提取方法(Extract Method)

// 重構前:長方法
function processOrder($order) {
    // 驗證和計算邏輯混在一起
    if (!$order->customer) throw new Exception('Customer required');
    if (!$order->items || count($order->items) == 0) throw new Exception('Items required');
    
    $total = 0;
    foreach ($order->items as $item) {
        $total += $item->price * $item->quantity;
    }
    
    if ($order->customer->type === 'VIP') {
        $total *= 0.9;
    }
    
    $order->total = $total;
    $order->status = 'processed';
}

// 重構後:提取方法
function processOrder($order) {
    validateOrder($order);
    $total = calculateTotal($order);
    $discountedTotal = applyDiscount($total, $order->customer);
    updateOrder($order, $discountedTotal);
}

2. 提取變數(Extract Variable)

將複雜表達式提取為有意義的變數名稱:

// 重構前:難以理解的複雜表達式
if ($user->age >= 18 && $user->hasVerifiedEmail() && $user->paymentMethods->count() > 0) {
    // 處理邏輯
}

// 重構後:使用有意義的變數
$isAdult = $user->age >= 18;
$hasVerifiedAccount = $user->hasVerifiedEmail();
$hasPaymentMethod = $user->paymentMethods->count() > 0;

if ($isAdult && $hasVerifiedAccount && $hasPaymentMethod) {
    // 處理邏輯
}

3. 消除重複(Remove Duplication)

建立 tests/Unit/Day10/RemoveDuplicationTest.php

<?php

use App\Services\UserService;

describe('Remove Duplication Refactoring', function () {
    // 重構前:重複的驗證邏輯
    class UserServiceBefore {
        public function updateEmail($userId, $email) {
            if (!$userId || trim($userId) === '') {
                throw new InvalidArgumentException('Invalid user ID');
            }
            if (!$email || !str_contains($email, '@')) {
                throw new InvalidArgumentException('Invalid email');
            }
            // 更新邏輯...
        }

        public function updatePassword($userId, $password) {
            if (!$userId || trim($userId) === '') {
                throw new InvalidArgumentException('Invalid user ID');
            }
            if (!$password || strlen($password) < 8) {
                throw new InvalidArgumentException('Invalid password');
            }
            // 更新邏輯...
        }
    }

    // 重構後:提取共用驗證
    class UserServiceAfter {
        private function validateUserId($userId) {
            if (!$userId || trim($userId) === '') {
                throw new InvalidArgumentException('Invalid user ID');
            }
        }

        public function updateEmail($userId, $email) {
            $this->validateUserId($userId);
            if (!$email || !str_contains($email, '@')) {
                throw new InvalidArgumentException('Invalid email');
            }
            // 更新邏輯...
        }

        public function updatePassword($userId, $password) {
            $this->validateUserId($userId);
            if (!$password || strlen($password) < 8) {
                throw new InvalidArgumentException('Invalid password');
            }
            // 更新邏輯...
        }
    }

    it('validates user ID consistently', function () {
        $serviceBefore = new UserServiceBefore();
        $serviceAfter = new UserServiceAfter();

        // 測試無效的 userId
        expect(fn() => $serviceBefore->updateEmail('', 'test@test.com'))
            ->toThrow(InvalidArgumentException::class, 'Invalid user ID');
        expect(fn() => $serviceAfter->updateEmail('', 'test@test.com'))
            ->toThrow(InvalidArgumentException::class, 'Invalid user ID');

        // 測試無效的 email
        expect(fn() => $serviceBefore->updateEmail('user123', 'invalid'))
            ->toThrow(InvalidArgumentException::class, 'Invalid email');
        expect(fn() => $serviceAfter->updateEmail('user123', 'invalid'))
            ->toThrow(InvalidArgumentException::class, 'Invalid email');
    });
});

重構的最佳實踐 ✨

1. 小步驟重構

不要一次性大重構,而是小步驟進行:

🔴 錯誤示範:
「我要花三天時間重構整個模組」

🟢 正確做法:
「我每次只重構一個方法,確保測試通過後再繼續」

執行步驟:

  1. 每次只重構一小部分
  2. 執行測試確保功能正常
  3. 提交版本控制(小步驟提交)
  4. 逐步改善整個程式碼結構

2. 每次重構後都執行測試

確保重構前後行為一致:

# 重構工作流程
$ pest tests/Unit/Day10/  # ✅ 確認測試綠燈
$ # 執行重構...
$ pest tests/Unit/Day10/  # 🧪 驗證行為未變
$ git commit -m "refactor: extract method for validation"

3. 保持向後相容性

重構時保持函數介面不變,避免破壞現有程式碼:

// ❌ 破壞性變更
getTotal()  // 原本
calculateTotal()  // 直接改名

// ✅ 向後相容
public function getTotal() {
    // 標記為過時,但保持可用
    trigger_error('getTotal() is deprecated, use calculateTotal() instead', E_USER_DEPRECATED);
    return $this->calculateTotal();
}

public function calculateTotal() {
    // 新的實作
}

4. 重構的時機選擇

適合重構的時機

  • ✅ 新增功能前
  • ✅ 修復 bug 時
  • ✅ Code Review 發現問題時
  • ✅ 理解程式碼有困難時

不適合重構的時機

  • ❌ 接近截止日期
  • ❌ 沒有測試保護
  • ❌ 程式碼即將被淘汰
  • ❌ 重構的收益不明確

📝 今天學到什麼?

透過今天的學習,我們掌握了:

核心概念

  1. 重構的本質:在保持外部行為不變的前提下改善內部結構
  2. 測試安全網:測試讓重構變得安全而有信心
  3. 重構技巧:提取方法、提取變數、消除重複等
  4. 重構流程:小步驟、持續測試、保持向後相容

10 天 TDD 學習成果 🎯

階段 核心技能 實戰能力
入門 (Day 1-3) ✅ Pest 框架✅ 基本斷言✅ 紅綠重構 能寫簡單的單元測試
基礎 (Day 4-6) ✅ 測試組織✅ 生命週期✅ 參數化測試 能組織大型測試套件
深化 (Day 7-9) ✅ Mock/Stub✅ 例外測試✅ 覆蓋率分析 能測試複雜場景
整合 (Day 10) ✅ 安全重構✅ 程式碼品質✅ 持續改進 能維護高品質程式碼

你已經掌握的能力

  • ✅ 能夠設定 Laravel Pest 測試環境
  • ✅ 熟練使用各種斷言方法
  • ✅ 理解並實踐 TDD 紅綠重構循環
  • ✅ 能夠組織和管理測試程式碼
  • ✅ 掌握測試生命週期鉤子
  • ✅ 會使用參數化測試減少重複
  • ✅ 能夠創建和使用測試替身
  • ✅ 知道如何測試例外情況
  • ✅ 理解測試覆蓋率的意義
  • ✅ 能在測試保護下安全重構

🎉 總結

今天我們學會了測試驅動的重構,這是 TDD 循環中「重構」步驟的深入實踐。有了測試作為安全網,我們可以大膽地改善程式碼結構,讓系統變得更好。

重構不是一次性的大工程,而是持續的小改進。就像園丁修剪花園,每天做一點,最終會有一個美麗的花園。

第一階段的基礎訓練圓滿完成!記住 TDD 的精髓:紅 → 綠 → 重構

明天我們將開始「Roman Numeral Kata」實戰,用 TDD 的方式解決真實的程式問題!


上一篇
Day 09 - 測試覆蓋率:你的測試真的夠完整嗎? 📊
下一篇
Day 11 - Kata 介紹與設置 🎯
系列文
Laravel Pest TDD 實戰:從零開始的測試驅動開發28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言